#! python
# -*- coding: utf-8 -*-
#
# This file is part of the PyUtilScripts project.
# Copyright (c) 2020-2025 zero <zero.kwok@foxmail.com>
#
# For the full copyright and license information, please view the LICENSE
# file that was distributed with this source code.
#

import os
import re
import sys
import shlex
import click
import shutil
import difflib
import filecmp
import argparse
import datetime
import traceback
from   typing import Tuple, List
from   pathlib import Path
from   natsort import natsorted
from   datetime import datetime
from   termcolor import cprint

from . import utils

CopyModes = ["update", "overwrite", "rename", "u", "o", "r"]

ListFileHeader = """# File list generated by fcopy on {}
# One file per line, relative to source directory
"""

ActionsFileHeader = """# Action plan for file copying (edit this file to change actions)
# Actions:
#
# c - Copy, when target doesn't exist
# u - Update, when target exists with mismatched metadata but source is newer
# o - Overwrite, when target exists, unconditionally copy and overwrite (--mode overwrite)
# r - Rename, when target exists, copy with incremented filename (--mode rename)
# i - Ignore, when target exists with mismatched metadata but deep comparison shows no difference or source is older
# s - Skip, when target exists and no differences found in shallow or deep comparison
#
# Example:
# c file1.txt                   Copy
# r file3.txt -> file(3).txt    Copy and Rename to file(3).txt
# o file2.txt                   Overwrite
# s file2.txt                   Skipped because the files are the same
#
# Source Directory: {Source}
# Target Directory: {Target}
"""

def read_file_list(filename, comment='#', keep_comments=False):
    """Read the list of files to copy from the manifest file."""
    if filename is None:
        return None
    with open(filename, 'r') as f:
        lines = [line.strip() for line in f.readlines()]

    # Filter out comments and empty lines
    files = []
    for line in lines:
        if not keep_comments:
            if line.startswith(comment) or not line:
                continue
        files.append(line)
    return files

def filter_match(file, patterns):
    if not patterns:
        return False
    return any(p.match(file) for p in patterns)

def read_file_filter(filename, comment='#'):
    patterns = []
    try:
        for line in read_file_list(filename, comment, False) or []:
            patterns.append(re.compile(line))
    except FileNotFoundError:
        pass # ignore missing file
    return patterns

def make_file_list(source, filters=[], verbose=False):
    files = []
    for root, _, names in os.walk(source):
        for n in names:
            filename = os.path.join(root, n)
            filename = os.path.relpath(filename, start=source)
            if filter_match(filename, filters):
                if verbose:
                    cprint(f"Filtered: {filename}", "yellow")
                continue
            files.append(filename)

    date = datetime.now().isoformat(timespec='seconds')
    head = ListFileHeader.format(date).splitlines()
    return head + natsorted(files)

def update_file_list(args):
    """Update the file list with the current contents of the source directory."""
    new = make_file_list(args.source, args.filter_patterns, args.verbose)
    old = []
    if os.path.exists(args.list):
        with open(args.list, 'r') as f:
            old = [line.strip() for line in f.readlines()]

    # 生成 unified diff
    diff = difflib.unified_diff(
        old,
        new,
        fromfile=args.list + ' (old)',
        tofile=args.list + ' (new)',
        lineterm=''
    )

    # 对 diff 行进行彩色渲染
    print('')
    for line in diff:
        if line.startswith('+'):
            cprint(line, 'green')  # 新增行：绿色
        elif line.startswith('-'):
            cprint(line, 'red')    # 删除行：红色
        elif line.startswith('@@'):
            cprint(line, 'cyan')   # 差异位置标记：青色
        else:
            cprint(line)

    # Ask for confirmation
    cprint(f"\nUpdate {args.list} with these changes? [y/N]", end=" ")
    confirm = input().strip().lower()
    if confirm != 'y':
        cprint('User Cancelled', "red", file=sys.stderr)
        return 1

    with open(args.list, 'w') as f:
        f.write('\n'.join(new))
    return 0

def file_cmp(file1, file2, stat1, stat2) -> Tuple[bool, int]:
    """Compare two files and return a tuple of (is_same, meta_cmp)."""
    # 浅比较, 比较文件大小和修改时间
    if stat1.st_size == stat2.st_size and stat1.st_mtime == stat2.st_mtime:
        return True, 0

    # 根据修改时间, 对比谁比较新
    meta_cmp = 1 if stat1.st_mtime >= stat2.st_mtime else -1

    # 深比较
    def _do_cmp(f1, f2):
        bufsize = 8*1024
        with open(f1, 'rb') as fp1, open(f2, 'rb') as fp2:
            while True:
                b1 = fp1.read(bufsize)
                b2 = fp2.read(bufsize)
                if b1 != b2:
                    return False
                if not b1:
                    return True
    if stat1.st_size == stat2.st_size and _do_cmp(file1, file2):
        return True, meta_cmp
    return False, meta_cmp

def increment_filename(directory, filename, rename_list):
    """
    在指定目录中为给定文件名生成不冲突的新文件名（仅文件名部分，不含路径）。
    支持多扩展名（如 .tar.gz）及已有的 (1)、(2) 递增模式。
    """
    directory  = Path(directory)
    filename   = Path(filename)
    components = filename.parent
    filename   = filename.name
    
    stem, *suffixes = filename.split('.')
    suffix = '.' + '.'.join(suffixes) if suffixes else ''

    # 若 stem 形如 "file(1)"，则提取基础名与编号
    match = re.match(r'^(.*?)(\((\d+)\))?$', stem )
    if match:
        stem = match.group(1)
        number = int(match.group(3)) if match.group(3) else 0
    else:
        number = 0

    # 除了判断文件系统中是否存在之外, 还要判断计划中产生的文件是否存在
    def exists(d, c, f):
        if (d / c / f).exists():
            return True
        if rename_list:
            if (c / f) in rename_list:
                return True
        return False

    # 初始候选
    candidate = filename
    while exists(directory, components, candidate):
        number += 1
        candidate = f"{stem}({number}){suffix}"

    return components / candidate 

class Action:
    """Action类用于描述操作行为"""
    def __init__(self, action: str, src: str, dst: str = '', common: str = None):
        self.action = action
        self.src = src
        self.dst = dst
        self.common = common

    def __iter__(self):
        return iter((self.action, self.src, self.dst))

    def natsorted(actions):
        priority = {c: i for i,c in enumerate(['c', 'u', 'o', 'r', 'i', 's'])}
        return natsorted(actions, key=lambda a: (priority[a.action], a.src, a.dst))

def make_actions(args):
    items: List[Action] = []
    rename_list: List[str] = []

    for file in args.manifest:
        if filter_match(file, args.filter_patterns):
            if args.verbose:
                cprint(f"Filtered: {file}", "yellow")
            continue

        source = os.path.normpath(os.path.join(args.source, file))
        target = os.path.normpath(os.path.join(args.target, file))
        try:
            stat1 = os.stat(source)
        except:
            # 源文件不存在则发出警告, 不视为错误
            cprint(f"Warn: SourceFileNotFound -> {source}", "magenta", file=sys.stderr)
            continue

        try:
            stat2 = os.stat(target)
        except FileNotFoundError:
            items.append(Action('c', file))
            continue

        if args.mode in ('r', 'rename'):
            file2 = increment_filename(args.target, file, rename_list)
            items.append(Action('c', file, str(file2)))
            rename_list.append(file2)
            continue

        elif args.mode in ('o', 'overwrite'):
            items.append(Action('o', file))
            continue

        # update mode
        is_same, meta_cmp = file_cmp(source, target, stat1, stat2)
        def common():
            if args.verbose > 0:
                return (
                    [
                        f"src: {utils.format_ftime(stat1.st_mtime)}, {utils.format_bytes(stat1.st_size)}",
                        f"dst: {utils.format_ftime(stat2.st_mtime)}, {utils.format_bytes(stat2.st_size)}",
                    ]
                    if meta_cmp != 0
                    else None
                )
            else:
                return {0: None, 1: ["src newer",], -1: ["dst newer",]}[meta_cmp]

        if is_same:
            items.append(Action('s' if meta_cmp == 0 else 'i', file, common=common()))
        else:
            items.append(Action('u' if meta_cmp >= 1 else 'i', file, common=common()))

    return Action.natsorted(items)

def parse_actions(lines, comment='#'):
    files = []
    for row, line in enumerate(lines):
        line = line.strip()
        if not line or line.startswith(comment):
            continue

        # Handle action-prefixed lines (for edit mode)
        if ' ' not in line:
            raise ValueError(f"Invalid line: {row}: {line}")
        fields = shlex.split(line, posix=os.name != 'nt')

        # remove comments fields
        result = []
        for f in fields:
            if f.startswith(comment):
                break
            result.append(f)
        fields = result

        if len(fields) == 2: 
            action, file1, file2 = fields + ['']
        elif len(fields) == 4 and '->' in fields:
            action, file1, _, file2 = fields
        else:
            raise ValueError(f"Invalid line: {row}: {line}, parse as: {fields}")
        files.append(Action(action, file1.strip(' \'"'), file2.strip(' \'"')))

    return files

def read_file_actions(filename, comment='#'):
    return parse_actions(read_file_list(filename, comment, True), comment)

def join_actions(actions:list[Action], header:str, args):
    lines = []
    for item in actions:
        line = f'{item.action}  "{item.src}"'
        if item.dst:
            line += f' -> "{item.dst}"'
        if item.common and args.verbose <= 0:
            line += f' # {",".join(item.common)}'
        lines.append(line)

        if args.verbose > 0: # 注释以独立的行存在
            for c in item.common or []:
                lines.append(f'   # {c}')

    header = header.format(Source=args.source, Target=args.target)
    return header.rstrip() + "\n\n" + "\n".join(lines) + "\n"

def print_actions(actions:list, header:str, args):
    print()
    cprint(f"The following actions will be performed:", "yellow")
    lines = join_actions(actions, header, args)
    for line in lines.splitlines():
        if not line:
            print()
            continue

        a = line[0]
        c = {"#": "dark_grey", "s": "yellow", "o": "green", "c": "green", " ": "white"}
        f = a if a in c else " "
        cprint(line, c[f])
    print()

def get_available_editor(defaults=("micro", "nano", "vim", "vi", "notepad")):
    """检查哪个编辑器可用，返回第一个可用的，否则返回 None"""
    if "EDITOR" in os.environ:
        defaults.insert(0, os.environ["EDITOR"])
        cprint(f'Preferred editor detected: {defaults[0]}', 'yellow')
    for editor in defaults:
        if shutil.which(editor):  # 检查是否在 PATH 里
            return editor
    return None

def edit_actions(actions:list, header:str, args) -> list:
    """
    使用 click.edit() 启动编辑器让用户编辑行动计划。
    返回: None 用户取消编辑或没保存
    """
    content = join_actions(actions, header, args)

    # 打开编辑器让用户编辑内容
    edited = click.edit(content, extension=".actions-todo", editor=get_available_editor())
    if edited is None:
        cprint("User canceled or didn't save, aborted.", "red", file=sys.stderr)
        raise SystemExit()

    # 解析用户编辑后的结果
    return parse_actions(edited.splitlines(), '#')

def copy_files(args):
    """Copy files from source directory to target directory with specified manifest"""
    args.manifest = read_file_list(args.list)
    if not args.manifest:
        cprint('Error: list file is empty or invalid.', "red", file=sys.stderr)
        return 1

    actions = make_actions(args)
    if not actions:
        cprint("Error: No actions to perform.", "red", file=sys.stderr)
        return 1

    if args.interactive:
        actions = edit_actions(actions, ActionsFileHeader, args)
    elif args.dry_run or args.verbose > 1:
        print_actions(actions, ActionsFileHeader, args)

    copied, skipped = 0, 0
    for action, file1, file2 in actions:
        file2 = file2 or file1
        if action == 's':
            skipped += 1
            continue

        if action in ('c', 'u', 'o', 'r'):
            source = os.path.normpath(os.path.join(args.source, file1))
            target = os.path.normpath(os.path.join(args.target, file2))
            if os.path.isdir(source):
                continue

            prefix = {'c': 'Copying', 'o': 'Replacing', 'u': 'Updating', 'r': 'Renaming'}[action]
            if args.dry_run:
                cprint(f"Dry run: {prefix}: {file1} -> {file2}", "cyan")
            elif args.verbose > 0:
                cprint(f"{prefix} {file1} -> {file2}", 'green')
            else:
                cprint(f"{prefix} {file1}", 'green')

            if not args.dry_run:
                try:
                    os.makedirs(os.path.dirname(target), exist_ok=True)
                    shutil.copy2(source, target)
                except OSError as e:
                    cprint(f"Error copying {source} to {target}: {e}", "red", file=sys.stderr)
            copied += 1

    cprint(f"Done. {copied} files copied, {skipped} skipped.")
    return 0

def main():
    try:
        parser = argparse.ArgumentParser(
            description="Copy files from source directory to target directory with flexible copy modes.", 
            formatter_class=argparse.ArgumentDefaultsHelpFormatter)
        parser = argparse.ArgumentParser(description='Copy files from source directory to target directory.')
        parser.add_argument('-l', '--list', default='fcopy.list', help='File containing the list of files to copy.')
        parser.add_argument("-s", "--source", required=True, help="source directory containing files to copy")
        parser.add_argument("-t", "--target", help="target directory where files will be copied")
        parser.add_argument("-m", "--mode", default="update", choices=CopyModes, help="copy mode: u|update, o|overwrite, r|rename")
        parser.add_argument("-i", "--interactive", action="store_true", help="Let the user edit the list of action plans to copy")
        parser.add_argument("-v", "--verbose", action="count", default=0, help="increase verbosity level (use -vv for more detail)")
        parser.add_argument('--filter', default='fcopy.filter', help='file containing blacklist regex patterns, one per line.')
        parser.add_argument("--update-list", action="store_true", help="update the --list file with current --source contents (with confirmation)")
        parser.add_argument("--dry-run", action="store_true", help="simulate operations without actually copying files")
        parser.add_argument('--debug', action='store_true', default=False, help=argparse.SUPPRESS)

        try:
            args = parser.parse_args()
        except SystemExit:
            print('\n'.join(parser.format_help().splitlines()[1:]))
            raise

        # argparse 默认会保留字符串中的引号
        for key in args.__dict__:
            if type(args.__dict__[key]) == str:
                args.__dict__[key] = args.__dict__[key].strip(' \'"')

        if args.debug:
            args.verbose = 2
            input('Wait for debugging and press Enter to continue...')

        if not args.list or not args.source:
            cprint("Error: Please provide the required arguments.", "red", file=sys.stderr)
            parser.print_help()
            return 1

        args.mode = args.mode.lower()
        args.source = os.path.normpath(os.path.abspath(args.source))
        if not os.path.isdir(args.source):
            cprint(f"Error: Source directory '{args.source}' does not exist", "red", file=sys.stderr)
            return 1

        # Read the filter file
        args.filter_patterns = read_file_filter(args.filter)
        if not args.filter_patterns and "_specified" in vars(args) and "filter" in args._specified:
            cprint(f"Warning: No valid patterns found in filter file '{args.filter}'.", "yellow")

        # Check if running in the terminal, because editor is only available in terminal
        if args.interactive and not sys.stdout.isatty():
            cprint("Warning: Not running in the terminal (may be a redirect or pipe)", "yellow")

        if args.update_list:
            return update_file_list(args)
        else:
            if args.target is None:
                cprint(f"Error: Please provide the target directory.")
                return 1
            args.target = os.path.normpath(os.path.abspath(args.target))
            return copy_files(args)

    except KeyboardInterrupt:
        cprint('\nKeyboard Interrupt', 'red', end='')
        return 1

if __name__ == "__main__":
    sys.exit(main())
